肖然:DDD分层架构的代码结构实战
The following article is from Thoughtworks洞见 Author 肖然
不同于其它的架构方法,领域驱动设计DDD(Domain Driven Design)提出了从业务设计到代码实现一致性的要求,不再对分析模型和实现模型进行区分。也就是说从代码的结构中我们可以直接理解业务的设计,命名得当的话,非程序人员也可以“读”代码。
然而在整个DDD的建模过程中,我们更多关注的是核心领域模型的建立,我们认为完成业务的需求就是在领域模型上的一系列操作(应用)。这些操作包括了对核心实体状态的改变,领域事件的存储,领域服务的调用等。在良好的领域模型之上,实现这些应用应该是轻松而愉快的。
笔者经历过很多次DDD的建模工作坊,在经历了数天一轮又一轮激烈讨论和不厌其烦的审视之后,大家欣慰地看着白板上各种颜色纸贴所展示出来的领域模型,成就感写满大家的脸庞。就在这个大功告成的时刻,往往会有人问:这个模型我们怎么落地呢?然后大家脸上的愉悦消失了,换上了对细节就是魔鬼的焦虑。但这是我们不可避免的实现细节,DDD的原始方法论中虽然给出了“分层架构”(Layered Architecture)的元模型,但如何分层却没有明确定义。
分层架构
在DDD方法提出后的数年里,分层架构的具体实现也经历了几代演进,直到Martin Fowler提炼出下图的分层实现架构后,才逐步为大家所认可。DDD的方法也得到了有效的补充,模型落地的问题也变得更容易,核心领域模型的范围也做出了比较明确的定义:包括了Domain,Service Layer和Repositories。
(Martin Fowler总结提出的分层架构实现,注意“Resources”是基于RESTful架构的抽象,我们也可以理解为更通用的针对外界的接口Interface。而HTTP Client主要是针对互联网的通信协议,Gateways实际才是交换过程中组装信息的逻辑所在。)
我们的核心实体(Entity)和值对象(Value Object)应该在Domain层,定义的领域服务(Domain Service)在Service Layer,而针对实体和值对象的存储和查询逻辑都应该在Repositories层。值得注意的是,不要把Entity的属性和行为分离到Domain和Service两层中去实现,即所谓的贫血模型,事实证明这样的实现方式会造成很大的维护问题。DDD战术建模中的元模型定义不应该在实现过程中被改变,作为元模型中元素之一的实体本身就应该包含针对自身的行为定义。
基于这个模型,下面我们来谈谈更具体的代码结构。对于这个分层架构还有疑惑的读者可以精读一下Martin的原文。有意思的一点是,这个模型的叙述实际是在微服务架构的测试文章中,其中深意值得大家体会。
这里需要明确的是,我们谈论代码结构的时候,针对的是一个经过DDD建模后的子问题域(参见战略设计篇),这是我们明确的组件化边界。是否进一步组件化,比如按照限界上下文(Bounded Context)模块化,或采用微服务架构服务化,核心实体都是进一步可能采用的组件化方法。从抽象层面讲,老马提炼的分层架构适用于面向业务的服务化架构,所以如果要进一步组件化也是可以按照这个代码结构来完成的。
总体的代码目录结构如下:
- DDD-Sample/src/
domain
gateways
interface
repositories
services
这个目录结构一一对应了前文的分层架构图。完整的案例代码请从GitHub下载。
可以看到实际上我们并没有建立外部存储(Data Mappers/ORM)和对外通信(HTTP Client)的目录。从领域模型和应用的角度,这两者都是我们不必关心的,能够验证整个领域模型的输入和输出就足够了。至于什么样的外部存储和外部通信机制是可以被“注入”的。这样的隔离是实现可独立部署服务的基础,也是我们能够测试领域模型实现的要求。
模型表达
根据分层架构确立了代码结构后,我们需要首先定义清楚我们的模型。如前面讲到的,这里主要涉及的是从战术建模过程中得到的核心实体和服务的定义。我们利用C++头文件(.h文件)来展示一个Domain模型的定义,案例灵感来源于DDD原著里的集装箱货运例子。
namespace domain{
struct Entity
{
int getId();
protected:
int id;
};
struct AggregateRoot: Entity
{
};
struct ValueObject
{
};
struct Provider
{
};
struct Delivery: ValueObject
{
Delivery(int);
int AfterDays;
};
struct Cargo: AggregateRoot
{
Cargo(Delivery*, int);
~Cargo();
void Delay(int);
private:
Delivery* delivery;
};
}
这个实现首先申明了元模型实体Entity和值对象ValueObject。实体一定会有一个标识id。在实体的基础上声明了DDD中的重要元素聚合根 AggregateRoot。根据定义,聚合根本身就应该是一个实体,所以AggregateRoot继承了Entity。
这个案例中我们定义了一个实体Cargo,同时也是一个聚合根。Delivery是一个值对象。虽然这里为了实现效率采用的是struct,在C++里可以理解为定义一个class类。
依赖关系
代码目录结构并不能表达分层体系中各层的依赖关系,比如Domain层是不应该依赖于其它任何一层的。维护各层的依赖关系是至关重要的,很多团队在实施的过程中都没有能够建立起这样的工程纪律,最后造成代码结构的混乱,领域模型也被打破。
根据分层架构的规则,我们可以看到示例中的代码结构如下图。
Domain是不依赖于任何的其它对象的。Repositories是依赖于Domain的,实现如下:引用了model.h。
#include "model.h"
#include <vector>
using namespace domain;
namespace repositories {
struct Repository
{
};
...
Services是依赖于Domain和Repositories的,实现如下:引用了model.h和repository.h
#include "model.h"
#include "repository.h"
using namespace domain;
using namespace repositories;
namespace services {
struct CargoProvider : Provider {
virtual void Confirm(Cargo* cargo){};
};
struct CargoService {
... ...
};
...
为了维护合理的依赖关系,依赖注入(Depedency Injection)是需要经常采用的实现模式,它作为解耦合的一种方法相信大家都不会陌生,具体定义参见这里。
在测试构建时,我们利用了一个IoC框架(依赖注入的实现)来构造了一个Api,并且把相关的依赖(如CargoService)注入给了这个Api。这样既没有破坏Interface和Service的单向依赖关系,又解决了测试过程中Api的实例化要求。
auto provider = std::make_shared< StubCargoProvider >();
api::Api* createApi() {
ContainerBuilder builder;
builder.registerType< CargoRepository >().singleInstance();
builder.registerInstance(provider).as<CargoProvider>();
builder.registerType< CargoService >().singleInstance();
builder.registerType<api::Api>().singleInstance();
auto container = builder.build();
std::shared_ptr<api::Api> api = container->resolve<api::Api>();
return api.get();
}
测试实现
有了领域模型,大家自然会想着如何去实现业务应用了,而实现应用的过程中一定会考虑到单元测试的设计。在构建高质量软件过程中,单元测试已经成为了标准规范,但高质量的单元测试却是困扰很多团队的普遍问题。很多时候设计测试比实现应用本身更加困难。
这里很难有一个固定标准来评判某个时间点的单元测试质量,但一个核心的原则是让用例尽量测试业务需求而不是实现方式本身。满足业务需求是我们的目标,实现方式可能有多种,我们不希望需要持续重构的实现代码影响到我们的测试用例。比如针对实现过程中的某个函数进行入参和出参的单元测试,当这个函数发生一点改变(即使是重命名),我们也需要改动测试。
测试驱动开发TDD无疑是一种好的实践,如果应用得当,它确实能够实现我们上述的原则,并且能够帮助我们交流业务的需求。比较有意思的是,在基于DDD建立的核心模型之上应用TDD似乎更加顺理成章。类比DDD和TDD虽然是不恰当的,但我们会发现两者在遵循的原则上是一致的,即都是面向业务做分解和设计:DDD就整个业务问题域进行了分解,形成子问题域;TDD就业务需求在实现时进行任务分解,从简单场景到复杂场景逐步通过测试驱动出实现。下面的测试用例展现了在核心模型上的TDD过程。
TEST(bc_demo_test, create_cargo)
{
api::CreateCargoMsg* msg = new api::CreateCargoMsg();
msg->Id = ID;
msg->AfterDays = AFTER_DAYS;
createCargo(msg);
EXPECT_EQ(msg->Id, provider->cargo_id);
EXPECT_EQ(msg->AfterDays, provider->after_days);
}
上面测试了收到一条创建信息后实例化一个Cargo的简单场景,要求创建后的Cargo的标识id跟信息里的一致,并且出货的日期一致。这个测试驱动出来一个Interface的Api::CreateCargo。
下面是另外一个测试推迟delay的场景,同样我们看到了驱动出的Api::Delay的实现。
TEST(bc_demo_test, delay_cargo)
{
api::Api* api = createApi();
api::CreateCargoMsg* msg = new api::CreateCargoMsg();
msg->Id = ID;
msg->AfterDays = AFTER_DAYS;
api->CreateCargo(msg);
api->Delay(ID,2);
EXPECT_EQ(ID, provider->cargo_id);
EXPECT_EQ(12, provider->after_days);
}
长期以来对于TDD这个实践大家都有架构设计上的疑惑,很多资深架构师担心完全从业务需求驱动出实现没法形成有效的技术架构,而且每次实现的重构成本都可能很高。DDD的引入从某种程度上解决了这个顾虑,通过前期的战略和战术建模确定了核心领域架构,这个架构是通过预先综合讨论决策的,考虑了更广阔的业务问题,较之TDD应用的业务需求层面更加宏观。在已有核心模型基础上我们也会发现测试用例的设计更容易从应用视角出发,从而降低了测试设计的难度。
关于预先设计
如果没有读战略篇直接看本文的读者肯定会提出关于预先设计的顾虑,毕竟DDD是被敏捷开发圈子认可的一种架构方式,其目标应该是构建架构模型的响应力。而这里给大家的更多是模式化的实现过程,好似从建模到代码一切都预先设计好了。
值得强调的是,我们仍然反对前期设计的大而全(Big-Design-Up-Front,BDUF)。但我们应该认可前期对核心领域模型的分析和设计,这样能够帮助我们更快地响应后续的业务变化(即在核心模型之上的应用)。这不代表着核心领域模型未来会一成不变,或者不能改变,而是经过统一建模的核心部分变化频率较之外部应用会低很多。如果核心领域模型也变化剧烈,那么我们可能就要考虑是否业务发生了根本性的变化,需要建立新的模型。
另外不能忘记我们预先定义的模型也是被局限在一个分解出来的核心问题域里的,也就是说我们并不希望一口气把整个复杂的业务领域里的所有模型都建立起来。这种范围的局限某种程度上也限制了我们预先设计的范围,促使我们更多用迭代的方式来看待建模工作本身。
最后显然我们应该有一个核心团队来守护核心领域模型,这不代表着任何模型的设计和改动都必须由这个团队的人做出(虽然有不少的团队确实是这样落地DDD的)。我们期望的是任何对核心模型的改动都能够通过这个核心团队来促进更大范围的交流和沟通。检验一个模型是否落地的唯一标准是应用这个模型的团队能否就模型本身达成共识。在这点上我们看到很多团队持续通过代码走查(code review)的方式在线上和线下实践基于核心模型的交流,从而起到了真正意义上的“守护”作用,让模型本身成为团队的共同责任。
实践DDD时仍然需要遵循“模型是用来交流的”的这一核心原则。我们希望本文介绍的方法及模式能够帮助大家更容易地交流领域模型,也算是对DDD战略和战术设计的一点补充。
推荐阅读
2021-08-03
2021-05-31
2021-03-31
2021-03-25
2021-03-29
2021-03-18
2021-08-16
2021-08-16
2021-08-09
2021-08-09
2021-08-24
2021-08-20